feat : Add support for glossary bulk upload and download. (#9726)

* feat  : Add support for glossary bulk upload and download.

* feat : add import widget

* fix : cy test

* feat: add import result screen

* feat : add import function

* fix: unit test

* chore: add localization

* test: add unit test for export glossary modal

* address comments

* test : add unit test for import result

* test : add unit test for glossary component

* chore: make export as deep link action

* test: add unit test for import

* style: add bg color to uploader widget

* fix: minor issues

* chore: cover all the import status
This commit is contained in:
Sachin Chaurasiya 2023-01-17 21:02:35 +05:30 committed by GitHub
parent 5bed64cbf5
commit 2e5bbfc5d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1601 additions and 198 deletions

View File

@ -47,3 +47,10 @@ src/antlr/generated
# Generated TS
src/generated/
# Assets
*.svg
*.png
*.ico
*.ttf

View File

@ -0,0 +1,4 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.8942 4.35565H8.18745L6.75751 2.59383C6.45219 2.22224 6.00426 2 5.52327 2H2.10252C1.22327 2 0.5 2.7299 0.5 3.60915V13.0683C0.5 13.9442 1.22327 14.6742 2.10252 14.6742H13.8975C14.7767 14.6742 15.5 13.9442 15.5 13.0649V5.94489C15.4967 5.06564 14.7734 4.35565 13.8942 4.35565ZM1.16353 13.0649V3.60915C1.16353 3.09486 1.58822 2.66353 2.10252 2.66353H5.52327C5.80193 2.66353 6.06732 2.79628 6.24322 3.01521L7.32812 4.35565H3.79796C2.92203 4.35565 2.19212 5.06564 2.19212 5.94489V14.0106H2.10252C1.58822 14.0106 1.16353 13.5792 1.16353 13.0649ZM14.8332 13.0649C14.8332 13.5759 14.4085 14.0106 13.8942 14.0106H2.85565V5.94489C2.85565 5.43391 3.28698 5.01918 3.79796 5.01918H13.8942C14.4051 5.01918 14.8332 5.43391 14.8332 5.94489V13.0649Z" fill="#7147E8" stroke="#7147E8" stroke-width="0.2"/>
<path d="M10.1663 10.6574C10.4649 10.2626 10.6441 9.76818 10.6441 9.23729C10.6441 7.93335 9.58244 6.875 8.27518 6.875C6.96792 6.875 5.90625 7.93335 5.90625 9.23729C5.90625 10.5412 6.96792 11.5996 8.27518 11.5996C8.80939 11.5996 9.30034 11.4238 9.69527 11.1251L10.6707 12.0973C10.737 12.1603 10.82 12.1935 10.9063 12.1935C10.9925 12.1935 11.0754 12.1603 11.1418 12.0973C11.2712 11.9678 11.2712 11.7589 11.1418 11.6294L10.1663 10.6574ZM6.56978 9.23397C6.56978 8.2983 7.33619 7.53521 8.27518 7.53521C9.21416 7.53521 9.98058 8.2983 9.98058 9.23397C9.98058 9.69849 9.79472 10.1165 9.48951 10.4251C9.48619 10.4284 9.47955 10.4317 9.47623 10.4383C9.47292 10.4417 9.4696 10.445 9.46628 10.4483C9.15775 10.7469 8.73637 10.9327 8.27518 10.9327C7.33619 10.9361 6.56978 10.173 6.56978 9.23397Z" fill="#7147E8" stroke="#7147E8" stroke-width="0.2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,31 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_17578_27974)">
<path
d="M17.114 6.88636L12.7686 1.67187C12.3688 1.19211 11.6319 1.19211 11.2321 1.67186L6.88672 6.88636"
stroke="#37352F"
stroke-width="1.5"
stroke-linecap="round"
/>
<rect
x="11.2324"
y="1.77344"
width="1.53409"
height="14.3182"
rx="0.767045"
fill="#37352F"
/>
<rect
x="0.75"
y="21.7148"
width="22.5"
height="1.53409"
rx="0.767045"
fill="#37352F"
/>
</g>
<defs>
<clipPath id="clip0_17578_27974">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 784 B

View File

@ -0,0 +1,30 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_17578_27965)">
<path
d="M17.114 9.95348L12.7686 15.168C12.3688 15.6477 11.6319 15.6477 11.2321 15.168L6.88672 9.95348"
stroke="#37352F"
stroke-width="1.5"
stroke-linecap="round"
/>
<rect
width="1.53409"
height="14.3182"
rx="0.767045"
transform="matrix(1 0 0 -1 11.2324 15.0664)"
fill="#37352F"
/>
<rect
x="0.75"
y="21.7148"
width="22.5"
height="1.53409"
rx="0.767045"
fill="#37352F"
/>
</g>
<defs>
<clipPath id="clip0_17578_27965">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 796 B

View File

@ -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(<ExportGlossaryModal {...mockProps} />);
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(<ExportGlossaryModal {...mockProps} />);
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(<ExportGlossaryModal {...mockProps} />);
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(<ExportGlossaryModal {...mockProps} />);
const cancelButton = await screen.getByText('label.cancel');
expect(cancelButton).toBeInTheDocument();
await act(async () => {
userEvent.click(cancelButton);
});
expect(mockCancel).toHaveBeenCalled();
});
});

View File

@ -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<Props> = ({
isModalOpen,
onCancel,
onOk,
glossaryName,
}) => {
const { t } = useTranslation();
const [fileName, setFileName] = useState<string>(
`${glossaryName}_${getCurrentLocaleDate()}`
);
const handleOnFileNameChange = (e: ChangeEvent<HTMLInputElement>) =>
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 (
<Modal
centered
cancelText={t('label.cancel')}
closable={false}
data-testid="export-glossary-modal"
okText={t('label.export')}
open={isModalOpen}
title={t('label.export-glossary-terms')}
onCancel={onCancel}
onOk={handleExport}>
<Form layout="vertical">
<Form.Item label="File Name:">
<Input
addonAfter=".csv"
data-testid="file-name-input"
value={fileName}
onChange={handleOnFileNameChange}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default ExportGlossaryModal;

View File

@ -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<OperationPermission>(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: (
<Row
className="tw-cursor-pointer manage-button"
data-testid="export-button"
onClick={(e) => {
e.stopPropagation();
handleGlossaryExport();
setShowActions(false);
}}>
<Col className="self-center" span={3}>
<ExportIcon width="20px" />
</Col>
<Col span={21}>
<Row>
<Col span={21}>
<Typography.Text
className="font-medium"
data-testid="export-button-title">
{t('label.export')}
</Typography.Text>
</Col>
<Col className="p-t-xss">
<Typography.Paragraph className="text-grey-muted text-xs m-b-0 line-height-16">
{t('label.export-glossary-terms')}
</Typography.Paragraph>
</Col>
</Row>
</Col>
</Row>
),
key: 'export-button',
},
{
label: (
<Row
className="tw-cursor-pointer manage-button"
data-testid="import-button"
onClick={(e) => {
e.stopPropagation();
handleGlossaryImport();
setShowActions(false);
}}>
<Col className="self-center" span={3}>
<ImportIcon width="20px" />
</Col>
<Col span={21}>
<Row>
<Col span={21}>
<Typography.Text
className="font-medium"
data-testid="import-button-title">
{t('label.import')}
</Typography.Text>
</Col>
<Col className="p-t-xss">
<Typography.Paragraph className="text-grey-muted text-xs m-b-0 line-height-16">
{t('label.import-glossary-terms')}
</Typography.Paragraph>
</Col>
</Row>
</Col>
</Row>
),
key: 'import-button',
},
]
: []),
{
label: (
<Space
@ -377,205 +479,227 @@ const GlossaryV1 = ({
return glossaryList.length ? (
<PageLayoutV1 leftPanel={fetchLeftPanel()}>
<div
className="tw-flex tw-justify-between tw-items-center"
data-testid="header">
<div className="tw-text-link tw-text-base" data-testid="category-name">
<TitleBreadcrumb
titleLinks={breadcrumb}
widthDeductions={
leftPanelWidth + addTermButtonWidth + manageButtonWidth + 20 // Additional deduction for margin on the right of leftPanel
}
/>
</div>
<div
className="tw-relative tw-flex tw-justify-between tw-items-center"
id="add-term-button">
<Tooltip
title={
createGlossaryTermPermission
? 'Add Term'
: NO_PERMISSION_FOR_ACTION
}>
<ButtonAntd
className="tw-h-8 tw-rounded tw-mr-2"
data-testid="add-new-tag-button"
disabled={!createGlossaryTermPermission}
type="primary"
onClick={handleAddGlossaryTermClick}>
Add term
</ButtonAntd>
</Tooltip>
<Dropdown
align={{ targetOffset: [-12, 0] }}
disabled={
isGlossaryActive
? !glossaryPermission.Delete
: !glossaryTermPermission.Delete
}
menu={{ items: manageButtonContent }}
open={showActions}
overlayStyle={{ width: '350px' }}
placement="bottomRight"
trigger={['click']}
onOpenChange={setShowActions}>
<Tooltip
title={
glossaryPermission.Delete || glossaryTermPermission.Delete
? isGlossaryActive
? 'Manage Glossary'
: 'Manage GlossaryTerm'
: NO_PERMISSION_FOR_ACTION
}>
<Button
className="tw-rounded tw-justify-center tw-w-8 tw-h-8 glossary-manage-button tw-flex"
data-testid="manage-button"
disabled={
!(glossaryPermission.Delete || glossaryTermPermission.Delete)
}
size="small"
theme="primary"
variant="outlined"
onClick={() => setShowActions(true)}>
<span>
<FontAwesomeIcon icon="ellipsis-vertical" />
</span>
</Button>
</Tooltip>
</Dropdown>
</div>
</div>
{isChildLoading ? (
<Loader />
{isImportAction ? (
<ImportGlossary glossaryName={selectedData.name} />
) : (
<>
<div className="edit-input">
{isNameEditing ? (
<Row align="middle" gutter={8}>
<Col>
<Input
className="input-width"
data-testid="displayName"
name="displayName"
value={displayName}
onChange={(e) => onDisplayNameChange(e.target.value)}
/>
</Col>
<Col>
<Button
className="icon-buttons"
data-testid="cancelAssociatedTag"
size="custom"
theme="primary"
variant="contained"
onMouseDown={() => setIsNameEditing(false)}>
<FontAwesomeIcon
className="tw-w-3.5 tw-h-3.5"
icon="times"
/>
</Button>
<Button
className="icon-buttons"
data-testid="saveAssociatedTag"
size="custom"
theme="primary"
variant="contained"
onMouseDown={onDisplayNameSave}>
<FontAwesomeIcon
className="tw-w-3.5 tw-h-3.5"
icon="check"
/>
</Button>
</Col>
</Row>
) : (
<Space className="display-name">
<Title className="tw-text-base" level={5}>
{getEntityName(selectedData)}
</Title>
<div
className="tw-flex tw-justify-between tw-items-center"
data-testid="header">
<div
className="tw-text-link tw-text-base"
data-testid="category-name">
<TitleBreadcrumb
titleLinks={breadcrumb}
widthDeductions={
leftPanelWidth + addTermButtonWidth + manageButtonWidth + 20 // Additional deduction for margin on the right of leftPanel
}
/>
</div>
<div
className="tw-relative tw-flex tw-justify-between tw-items-center"
id="add-term-button">
<Tooltip
title={
createGlossaryTermPermission
? 'Add Term'
: NO_PERMISSION_FOR_ACTION
}>
<ButtonAntd
className="tw-h-8 tw-rounded tw-mr-2"
data-testid="add-new-tag-button"
disabled={!createGlossaryTermPermission}
type="primary"
onClick={handleAddGlossaryTermClick}>
Add term
</ButtonAntd>
</Tooltip>
<Dropdown
align={{ targetOffset: [-12, 0] }}
disabled={
isGlossaryActive
? !glossaryPermission.Delete
: !glossaryTermPermission.Delete
}
menu={{ items: manageButtonContent }}
open={showActions}
overlayStyle={{ width: '350px' }}
placement="bottomRight"
trigger={['click']}
onOpenChange={setShowActions}>
<Tooltip
title={
editDisplayNamePermission
? 'Edit Displayname'
glossaryPermission.Delete || glossaryTermPermission.Delete
? isGlossaryActive
? 'Manage Glossary'
: 'Manage GlossaryTerm'
: NO_PERMISSION_FOR_ACTION
}>
<ButtonAntd
className="m-b-xss"
disabled={!editDisplayNamePermission}
type="text"
onClick={() => setIsNameEditing(true)}>
<SVGIcons
alt="icon-tag"
className="tw-mx-1"
icon={Icons.EDIT}
width="16"
/>
</ButtonAntd>
<Button
className="tw-rounded tw-justify-center tw-w-8 tw-h-8 glossary-manage-button tw-flex"
data-testid="manage-button"
disabled={
!(
glossaryPermission.Delete ||
glossaryTermPermission.Delete
)
}
size="small"
theme="primary"
variant="outlined"
onClick={() => setShowActions(true)}>
<span>
<FontAwesomeIcon icon="ellipsis-vertical" />
</span>
</Button>
</Tooltip>
</Space>
)}
</Dropdown>
</div>
</div>
<Space className="m-b-md" data-testid="updated-by-container" size={8}>
<Typography.Text className="text-grey-muted">
{t('label.updated-by')} -
</Typography.Text>
{selectedData.updatedBy && selectedData.updatedAt ? (
<>
{' '}
<ProfilePicture
displayName={selectedData.updatedBy}
// There is no user id present in response
id=""
name={selectedData.updatedBy || ''}
textClass="text-xs"
width="20"
/>
<Typography.Text data-testid="updated-by-details">
<Link to={getUserPath(selectedData.updatedBy ?? '')}>
{selectedData.updatedBy}
</Link>{' '}
{t('label.on-lowercase')}{' '}
{formatDateTime(selectedData.updatedAt || 0)}
{isChildLoading ? (
<Loader />
) : (
<>
<div className="edit-input">
{isNameEditing ? (
<Row align="middle" gutter={8}>
<Col>
<Input
className="input-width"
data-testid="displayName"
name="displayName"
value={displayName}
onChange={(e) => onDisplayNameChange(e.target.value)}
/>
</Col>
<Col>
<Button
className="icon-buttons"
data-testid="cancelAssociatedTag"
size="custom"
theme="primary"
variant="contained"
onMouseDown={() => setIsNameEditing(false)}>
<FontAwesomeIcon
className="tw-w-3.5 tw-h-3.5"
icon="times"
/>
</Button>
<Button
className="icon-buttons"
data-testid="saveAssociatedTag"
size="custom"
theme="primary"
variant="contained"
onMouseDown={onDisplayNameSave}>
<FontAwesomeIcon
className="tw-w-3.5 tw-h-3.5"
icon="check"
/>
</Button>
</Col>
</Row>
) : (
<Space className="display-name">
<Title className="tw-text-base" level={5}>
{getEntityName(selectedData)}
</Title>
<Tooltip
title={
editDisplayNamePermission
? 'Edit Displayname'
: NO_PERMISSION_FOR_ACTION
}>
<ButtonAntd
className="m-b-xss"
disabled={!editDisplayNamePermission}
type="text"
onClick={() => setIsNameEditing(true)}>
<SVGIcons
alt="icon-tag"
className="tw-mx-1"
icon={Icons.EDIT}
width="16"
/>
</ButtonAntd>
</Tooltip>
</Space>
)}
</div>
<Space
className="m-b-md"
data-testid="updated-by-container"
size={8}>
<Typography.Text className="text-grey-muted">
{t('label.updated-by')} -
</Typography.Text>
</>
) : (
'--'
)}
</Space>
{!isEmpty(selectedData) &&
(isGlossaryActive ? (
<GlossaryDetails
glossary={selectedData as Glossary}
handleUserRedirection={handleUserRedirection}
permissions={glossaryPermission}
updateGlossary={updateGlossary}
/>
) : (
<GlossaryTermsV1
assetData={assetData}
currentPage={currentPage}
glossaryTerm={selectedData as GlossaryTerm}
handleGlossaryTermUpdate={handleGlossaryTermUpdate}
handleUserRedirection={handleUserRedirection}
permissions={glossaryTermPermission}
onAssetPaginate={onAssetPaginate}
onRelatedTermClick={onRelatedTermClick}
/>
))}
{selectedData.updatedBy && selectedData.updatedAt ? (
<>
{' '}
<ProfilePicture
displayName={selectedData.updatedBy}
// There is no user id present in response
id=""
name={selectedData.updatedBy || ''}
textClass="text-xs"
width="20"
/>
<Typography.Text data-testid="updated-by-details">
<Link to={getUserPath(selectedData.updatedBy ?? '')}>
{selectedData.updatedBy}
</Link>{' '}
{t('label.on-lowercase')}{' '}
{formatDateTime(selectedData.updatedAt || 0)}
</Typography.Text>
</>
) : (
'--'
)}
</Space>
{!isEmpty(selectedData) &&
(isGlossaryActive ? (
<GlossaryDetails
glossary={selectedData as Glossary}
handleUserRedirection={handleUserRedirection}
permissions={glossaryPermission}
updateGlossary={updateGlossary}
/>
) : (
<GlossaryTermsV1
assetData={assetData}
currentPage={currentPage}
glossaryTerm={selectedData as GlossaryTerm}
handleGlossaryTermUpdate={handleGlossaryTermUpdate}
handleUserRedirection={handleUserRedirection}
permissions={glossaryTermPermission}
onAssetPaginate={onAssetPaginate}
onRelatedTermClick={onRelatedTermClick}
/>
))}
</>
)}
{selectedData && (
<EntityDeleteModal
bodyText={getEntityDeleteMessage(selectedData.name, '')}
entityName={selectedData.name}
entityType="Glossary"
loadingState={deleteStatus}
visible={isDelete}
onCancel={() => setIsDelete(false)}
onConfirm={handleDelete}
/>
)}
{isExportAction && (
<ExportGlossaryModal
glossaryName={selectedData.name}
isModalOpen={isExportAction}
onCancel={handleCancelGlossaryExport}
onOk={handleCancelGlossaryExport}
/>
)}
</>
)}
{selectedData && (
<EntityDeleteModal
bodyText={getEntityDeleteMessage(selectedData.name, '')}
entityName={selectedData.name}
entityType="Glossary"
loadingState={deleteStatus}
visible={isDelete}
onCancel={() => setIsDelete(false)}
onConfirm={handleDelete}
/>
)}
</PageLayoutV1>
) : (
<PageLayoutV1>

View File

@ -43,3 +43,8 @@ export type GlossaryV1Props = {
handleUserRedirection?: (name: string) => void;
isChildLoading: boolean;
};
export enum GlossaryAction {
EXPORT = 'export',
IMPORT = 'import',
}

View File

@ -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 }) => <a>{children}</a>),
}));
jest.mock('components/GlossaryDetails/GlossaryDetails.component', () => {
return jest.fn().mockReturnValue(<>Glossary-Details component</>);
});
jest.mock('react-router-dom', () => ({
Link: jest.fn().mockImplementation(({ children }) => <a>{children}</a>),
}));
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(
<div data-testid="export-glossary">ExportGlossaryModal</div>
)
);
jest.mock('./ImportGlossary/ImportGlossary', () =>
jest
.fn()
.mockReturnValue(<div data-testid="import-glossary">ImportGlossary</div>)
);
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(<GlossaryV1 {...mockProps} />);
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(<GlossaryV1 {...mockProps} />);
const exportGlossary = getByTestId(container, 'export-glossary');
expect(exportGlossary).toBeInTheDocument();
});
});
it('Should render export and import option', async () => {
await act(async () => {
const { container } = render(<GlossaryV1 {...mockProps} />);
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();
});
});
});

View File

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

View File

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

View File

@ -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(<div data-testid="breadcrumb">Breadcrumb</div>)
);
jest.mock('components/Loader/Loader', () =>
jest.fn().mockReturnValue(<div data-testid="loader">Loader</div>)
);
jest.mock('../ImportResult/ImportResult', () =>
jest
.fn()
.mockReturnValue(<div data-testid="import-results">Import Result</div>)
);
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(<ImportGlossary glossaryName={glossaryName} />);
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(<ImportGlossary glossaryName={glossaryName} />);
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(<ImportGlossary glossaryName={glossaryName} />);
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(<ImportGlossary glossaryName={glossaryName} />);
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(<ImportGlossary glossaryName={glossaryName} />);
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();
});
});

View File

@ -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<Props> = ({ glossaryName }) => {
const { t } = useTranslation();
const history = useHistory();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isPreview, setIsPreview] = useState<boolean>(false);
const [fileName, setFileName] = useState<string>('');
const [csvFileResult, setCsvFileResult] = useState<string>('');
const [csvImportResult, setCsvImportResult] = useState<CSVImportResult>();
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 (
<Row gutter={[16, 16]}>
<Col span={24}>
<TitleBreadcrumb titleLinks={breadcrumbList} />
</Col>
<Col span={24}>
<Space className="w-full justify-between">
<Title data-testid="title" level={5}>
{isPreview ? glossaryName : t('label.import-glossary-terms')}
</Title>
{isPreview && !isUndefined(csvImportResult) && !isFailure && (
<Button
data-testid="import-button"
loading={isLoading}
type="primary"
onClick={handleImport}>
{t('label.import')}
</Button>
)}
</Space>
</Col>
{isPreview && !isUndefined(csvImportResult) ? (
<Col span={24}>
<ImportResult csvImportResult={csvImportResult} />
</Col>
) : (
<Col span={24}>
{isUndefined(csvImportResult) ? (
<Dragger
accept=".csv"
beforeUpload={(file) => {
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 ? (
<Loader />
) : (
<>
<Space
align="center"
className="w-full justify-center"
direction="vertical"
size={16}>
<ImportIcon height={58} width={58} />
<Typography.Text>
{t('label.drag-and-drop-files-here')}
</Typography.Text>
</Space>
<Divider plain>
<Typography.Text type="secondary">
{t('label.or-lowercase')}
</Typography.Text>
</Divider>
<Button data-testid="upload-button">
<Space>
<BrowseFileIcon width={16} />
<Typography.Text className="text-primary">
{t('label.browse-csv-file')}
</Typography.Text>
</Space>
</Button>
</>
)}
</Dragger>
) : (
<Card>
<Space
align="center"
className="w-full justify-center p-lg"
direction="vertical"
size={16}>
{isSuccess && (
<SuccessBadgeIcon data-testid="success-badge" width={58} />
)}
{isFailure && (
<FailBadgeIcon data-testid="failure-badge" width={58} />
)}
{showSuccessResult && (
<>
<Typography.Text>
<strong data-testid="file-name">{fileName}</strong>{' '}
{`${t('label.is-ready-for-preview')}.`}
</Typography.Text>
<Space size={16}>
<Button
data-testid="cancel-button"
onClick={() => setCsvImportResult(undefined)}>
{t('label.cancel')}
</Button>
<Button
data-testid="preview-button"
type="primary"
onClick={() => setIsPreview(true)}>
{t('label.preview')}
</Button>
</Space>
</>
)}
{showAbortedResult && (
<>
<Typography.Text
className="text-center"
data-testid="abort-reason">
<strong className="d-block">{t('label.aborted')}</strong>{' '}
{csvImportResult.abortReason}
</Typography.Text>
<Button
data-testid="cancel-button"
onClick={() => setCsvImportResult(undefined)}>
{t('label.cancel')}
</Button>
</>
)}
</Space>
</Card>
)}
</Col>
)}
</Row>
);
};
export default ImportGlossary;

View File

@ -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(<div>RichTextViewer</div>)
);
describe('Import Results', () => {
it('Should render the results', async () => {
render(
<ImportResult csvImportResult={mockCsvImportResult as CSVImportResult} />
);
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(
<ImportResult csvImportResult={mockCsvImportResult as CSVImportResult} />
);
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();
});
});

View File

@ -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<Props> = ({ csvImportResult }) => {
const { t } = useTranslation();
const parsedRecords: GlossaryCSVRecord[] = useMemo(() => {
const importResult = csvImportResult?.importResultsCsv;
if (importResult) {
return parseCSV(importResult);
} else {
return [];
}
}, [csvImportResult]);
const columns: ColumnsType<GlossaryCSVRecord> = useMemo(
() => [
{
title: t('label.status'),
dataIndex: 'status',
key: 'status',
render: (status: GlossaryCSVRecord['status']) => {
return (
<Typography.Text
className={classNames(
{
'text-success': status === Status.Success,
},
{ 'text-failure': status === Status.Failure }
)}>
{status}
</Typography.Text>
);
},
},
{
title: t('label.detail-plural'),
dataIndex: 'details',
key: 'details',
render: (details: GlossaryCSVRecord['details']) => {
return <Typography.Text>{details ?? '--'}</Typography.Text>;
},
},
{
title: t('label.parent'),
dataIndex: 'parent',
key: 'parent',
render: (parent: GlossaryCSVRecord['parent']) => {
return <Typography.Text>{parent ?? '--'}</Typography.Text>;
},
},
{
title: t('label.name'),
dataIndex: 'name*',
key: 'name',
render: (name: GlossaryCSVRecord['name*']) => {
return <Typography.Text>{name}</Typography.Text>;
},
},
{
title: t('label.display-name'),
dataIndex: 'displayName',
key: 'displayName',
render: (displayName: GlossaryCSVRecord['displayName']) => {
return <Typography.Text>{displayName ?? '--'}</Typography.Text>;
},
},
{
title: t('label.description'),
dataIndex: 'description*',
key: 'description',
render: (description: GlossaryCSVRecord['description*']) => {
return <RichTextEditorPreviewer markdown={description ?? '--'} />;
},
},
{
title: t('label.synonyms'),
dataIndex: 'synonyms',
key: 'synonyms',
render: (synonyms: GlossaryCSVRecord['synonyms']) => {
return <Typography.Text>{synonyms ?? '--'}</Typography.Text>;
},
},
{
title: t('label.tag-plural'),
dataIndex: 'tags',
key: 'tags',
render: (tags: GlossaryCSVRecord['tags']) => {
return <Typography.Text>{tags ?? '--'}</Typography.Text>;
},
},
],
[]
);
return (
<Row data-testid="import-results" gutter={[16, 16]}>
<Col span={24}>
<Space>
<div>
<Typography.Text type="secondary">{`${t(
'label.number-of-rows'
)}: `}</Typography.Text>
<span className="text-600" data-testid="processed-row">
{csvImportResult.numberOfRowsProcessed}
</span>
</div>
{' | '}
<div>
<Typography.Text type="secondary">{`${t(
'label.passed'
)}: `}</Typography.Text>
<span className="text-600" data-testid="passed-row">
{csvImportResult.numberOfRowsPassed}
</span>
</div>
{' | '}
<div>
<Typography.Text type="secondary">{`${t(
'label.failed'
)}: `}</Typography.Text>
<span className="text-600" data-testid="failed-row">
{csvImportResult.numberOfRowsFailed}
</span>
</div>
</Space>
</Col>
<Col span={24}>
<Table
bordered
columns={columns}
data-testid="import-result-table"
dataSource={parsedRecords}
pagination={false}
rowKey="name"
size="small"
/>
</Col>
</Row>
);
};
export default ImportResult;

View File

@ -363,6 +363,12 @@ const AuthenticatedAppRouter: FunctionComponent = () => {
hasPermission={glossaryPermission}
path={ROUTES.GLOSSARY_DETAILS}
/>
<AdminProtectedRoute
exact
component={GlossaryPageV1}
hasPermission={glossaryPermission}
path={ROUTES.GLOSSARY_DETAILS_WITH_ACTION}
/>
<AdminProtectedRoute
exact
component={GlossaryPageV1}

View File

@ -114,6 +114,7 @@ export const LOG_ENTITY_TYPE = ':logEntityType';
export const INGESTION_NAME = ':ingestionName';
export const LOG_ENTITY_NAME = ':logEntityName';
export const KPI_NAME = ':kpiName';
export const PLACEHOLDER_ACTION = ':action';
export const pagingObject = { after: '', before: '', total: 0 };
@ -209,6 +210,7 @@ export const ROUTES = {
GLOSSARY: '/glossary',
ADD_GLOSSARY: '/add-glossary',
GLOSSARY_DETAILS: `/glossary/${PLACEHOLDER_GLOSSARY_NAME}`,
GLOSSARY_DETAILS_WITH_ACTION: `/glossary/${PLACEHOLDER_GLOSSARY_NAME}/action/${PLACEHOLDER_ACTION}`,
ADD_GLOSSARY_TERMS: `/glossary/${PLACEHOLDER_GLOSSARY_NAME}/add-term`,
GLOSSARY_TERMS: `/glossary/${PLACEHOLDER_GLOSSARY_NAME}/term/${PLACEHOLDER_GLOSSARY_TERMS_FQN}`,
ADD_GLOSSARY_TERMS_CHILD: `/glossary/${PLACEHOLDER_GLOSSARY_NAME}/term/${PLACEHOLDER_GLOSSARY_TERMS_FQN}/add-term`,

View File

@ -61,6 +61,7 @@
"bot-lowercase": "bot",
"bot-plural": "Bots",
"broker-plural": "Brokers",
"browse-csv-file": "Browse csv file",
"ca-certs": "CA Certs",
"cancel": "Cancel",
"change-entity": "Change {{entity}}",
@ -174,6 +175,7 @@
"display-name-lowercase": "display name",
"doc-plural": "Docs",
"domain": "Domain",
"drag-and-drop-files-here": "Drag & drop files here",
"edge-information": "Edge Information",
"edit": "Edit",
"edit-amp-accept-suggestion": "Edit & Accept Suggestion",
@ -213,6 +215,8 @@
"expand-all": "Expand All",
"explore": "Explore",
"explore-now": "Explore Now",
"export": "Export",
"export-glossary-terms": "Export glossary terms",
"failed": "Failed",
"feature": "Feature",
"feature-lowercase": "feature",
@ -250,6 +254,8 @@
"hide": "Hide",
"hyper-parameter-plural": "Hyper Parameters",
"idle": "Idle",
"import": "Import",
"import-glossary-terms": "Import glossary terms",
"inactive-announcement-plural": "Inactive Announcements",
"include": "Include",
"include-entity": "Include {{entity}}",
@ -265,6 +271,7 @@
"interval-type": "Interval Type",
"interval-unit": "Interval Unit",
"invalid-name": "Invalid Name",
"is-ready-for-preview": "is ready for preview",
"join-team": "Join Team",
"json-data": "JSON Data",
"jump-to-end": "Jump to End",
@ -331,6 +338,7 @@
"not-null": "Not Null",
"not-used": "Not Used",
"notification-plural": "Notifications",
"number-of-rows": "Number of rows",
"of-lowercase": "of",
"okta": "Okta",
"okta-email": "Okta Service Account Email",
@ -351,6 +359,7 @@
"owner-lowercase": "owner",
"owner-plural": "Owners",
"page-views-by-data-asset-plural": "Page Views by Data Assets",
"parent": "Parent",
"partition-lowercase-plural": "partitions",
"partition-plural": "Partitions",
"partitions": "Partitions",
@ -377,6 +386,7 @@
"policy-plural": "Policies",
"posted-on-lowercase": "posted on",
"press": "Press",
"preview": "Preview",
"primary-key": "Primary Key",
"private-key": "PrivateKey",
"profile": "Profile",
@ -497,6 +507,7 @@
"success": "Success",
"suite": "Suite",
"summary": "Summary",
"synonyms": "Synonyms",
"table": "Table",
"table-entity-text": "Table {{entityText}}",
"table-lowercase": "table",

View File

@ -13,6 +13,7 @@
import { AxiosResponse } from 'axios';
import { Operation } from 'fast-json-patch';
import { CSVImportResult } from 'generated/type/csvImportResult';
import { ModifiedGlossaryData } from 'pages/GlossaryPage/GlossaryPageV1.component';
import { CreateGlossary } from '../generated/api/data/createGlossary';
import { CreateGlossaryTerm } from '../generated/api/data/createGlossaryTerm';
@ -152,3 +153,28 @@ export const deleteGlossaryTerm = (id: string) => {
`/glossaryTerms/${id}?recursive=true&hardDelete=true`
);
};
export const exportGlossaryInCSVFormat = async (glossaryName: string) => {
const response = await APIClient.get<string>(
`/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<string, AxiosResponse<CSVImportResult>>(
`/glossaries/name/${glossaryName}/import?dryRun=${dryRun}`,
data,
configOptions
);
return response.data;
};

View File

@ -75,6 +75,14 @@
color: @text-color-secondary;
}
.text-success {
color: #008376;
}
.text-failure {
color: #cb2431;
}
// text alignment
.text-center {

View File

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

View File

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