Minor: fix glossary add term not working (#14148)

* fix glossary add term not working

* minor fixes

* added unit test
This commit is contained in:
Ashish Gupta 2023-11-30 12:36:49 +05:30 committed by GitHub
parent 29296ba939
commit 4e90650aa2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 299 additions and 65 deletions

View File

@ -12,9 +12,9 @@
*/
import { DefaultOptionType } from 'antd/lib/select';
import { PagingResponse } from 'Models';
import { Tag } from '../../generated/entity/classification/tag';
import { GlossaryTerm } from '../../generated/entity/data/glossaryTerm';
import { Paging } from '../../generated/type/paging';
export type SelectOption = {
label: string;
@ -30,12 +30,10 @@ export interface AsyncSelectListProps {
defaultValue?: string[];
value?: string[];
initialOptions?: SelectOption[];
filterOptions?: string[]; // array of fqn
onChange?: (option: DefaultOptionType | DefaultOptionType[]) => void;
fetchOptions: (
search: string,
page: number
) => Promise<{
data: SelectOption[];
paging: Paging;
}>;
) => Promise<PagingResponse<SelectOption[]>>;
}

View File

@ -0,0 +1,230 @@
/*
* 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,
findByRole,
fireEvent,
render,
screen,
waitForElement,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { ASYNC_SELECT_MOCK } from '../../mocks/AsyncSelect.mock';
import AsyncSelectList from './AsyncSelectList';
jest.mock('lodash', () => {
const module = jest.requireActual('lodash');
module.debounce = jest.fn((fn) => fn);
return module;
});
jest.mock('../../components/Loader/Loader', () =>
jest.fn().mockImplementation(() => <div>Loader</div>)
);
jest.mock('../Tag/TagsV1/TagsV1.component', () =>
jest.fn().mockImplementation(() => <div>TagsV1</div>)
);
jest.mock('../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
jest.mock('../../utils/TagsUtils', () => ({
getTagDisplay: jest.fn().mockReturnValue('tags'),
tagRender: jest.fn().mockReturnValue(<p>Tags Render</p>),
}));
const mockOnChange = jest.fn();
const mockFetchOptions = jest.fn().mockReturnValue({
data: [],
paging: { total: 0 },
});
const mockProps = {
fetchOptions: mockFetchOptions,
};
describe('Test AsyncSelect List Component', () => {
it('Should render component', async () => {
render(<AsyncSelectList {...mockProps} />);
expect(screen.getByTestId('tag-selector')).toBeInTheDocument();
});
it('Should render value if passed', async () => {
render(<AsyncSelectList {...mockProps} value={['select-1']} />);
expect(screen.getByTestId('tag-selector')).toBeInTheDocument();
expect(screen.getByTitle('select-1')).toBeInTheDocument();
});
it('Should trigger fetchOptions when focus on select input', async () => {
render(<AsyncSelectList {...mockProps} />);
expect(screen.getByTestId('tag-selector')).toBeInTheDocument();
await act(async () => {
fireEvent.focus(screen.getByTestId('tag-selector'));
});
expect(mockFetchOptions).toHaveBeenCalledWith('', 1);
});
it('Should call fetchOptions with multiple focus operation', async () => {
render(<AsyncSelectList {...mockProps} />);
expect(screen.getByTestId('tag-selector')).toBeInTheDocument();
await act(async () => {
// first focus
fireEvent.focus(screen.getByTestId('tag-selector'));
expect(mockFetchOptions).toHaveBeenCalledWith('', 1);
fireEvent.blur(screen.getByTestId('tag-selector'));
// second focus
fireEvent.focus(screen.getByTestId('tag-selector'));
});
expect(mockFetchOptions).toHaveBeenCalledWith('', 1);
});
it('Should call fetchOptions with search data when user type text', async () => {
render(<AsyncSelectList {...mockProps} />);
const searchInput = (await screen.findByRole(
'combobox'
)) as HTMLInputElement;
expect(searchInput.value).toBe('');
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'entity-tags' } });
});
expect(searchInput.value).toBe('entity-tags');
expect(mockFetchOptions).toHaveBeenCalledWith('entity-tags', 1);
expect(mockFetchOptions).toHaveBeenCalledTimes(2);
});
it('Should render options if provided', async () => {
mockFetchOptions.mockResolvedValueOnce(ASYNC_SELECT_MOCK);
render(<AsyncSelectList {...mockProps} />);
const selectInput = await findByRole(
screen.getByTestId('tag-selector'),
'combobox'
);
await act(async () => {
userEvent.click(selectInput);
});
// wait for list to render, checked with item having in the list
await waitForElement(() => screen.findByTestId('tag-tags-6'));
const item = screen.queryByText('tags-6');
expect(item).toBeInTheDocument();
expect(mockFetchOptions).toHaveBeenCalledWith('', 1);
expect(mockFetchOptions).toHaveBeenCalledTimes(1);
});
it('Should filter options based on provided filterOptions', async () => {
mockFetchOptions.mockResolvedValueOnce(ASYNC_SELECT_MOCK);
render(<AsyncSelectList {...mockProps} filterOptions={['tags-1']} />);
const selectInput = await findByRole(
screen.getByTestId('tag-selector'),
'combobox'
);
await act(async () => {
userEvent.click(selectInput);
});
// wait for list to render, checked with item having in the list
await waitForElement(() => screen.findByTestId('tag-tags-0'));
const filteredItem = screen.queryByText('tags-1');
expect(filteredItem).not.toBeInTheDocument();
expect(mockFetchOptions).toHaveBeenCalledWith('', 1);
expect(mockFetchOptions).toHaveBeenCalledTimes(1);
});
it('Should not trigger onChange on item selection', async () => {
mockFetchOptions.mockResolvedValueOnce(ASYNC_SELECT_MOCK);
render(<AsyncSelectList {...mockProps} mode="multiple" />);
const selectInput = await findByRole(
screen.getByTestId('tag-selector'),
'combobox'
);
await act(async () => {
userEvent.click(selectInput);
});
await waitForElement(() => screen.getByTestId('tag-tags-0'));
await act(async () => {
fireEvent.click(screen.getByTestId('tag-tags-0'));
});
expect(mockOnChange).not.toHaveBeenCalled();
expect(mockFetchOptions).toHaveBeenCalledTimes(1);
});
it('Should trigger onChange on item selection', async () => {
mockFetchOptions.mockResolvedValueOnce(ASYNC_SELECT_MOCK);
render(
<AsyncSelectList {...mockProps} mode="multiple" onChange={mockOnChange} />
);
const selectInput = await findByRole(
screen.getByTestId('tag-selector'),
'combobox'
);
await act(async () => {
userEvent.click(selectInput);
});
await waitForElement(() => screen.getByTestId('tag-tags-0'));
await act(async () => {
fireEvent.click(screen.getByTestId('tag-tags-0'));
});
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockFetchOptions).toHaveBeenCalledTimes(1);
});
});

View File

@ -43,6 +43,7 @@ const AsyncSelectList: FC<AsyncSelectListProps> = ({
fetchOptions,
debounceTimeout = 800,
initialOptions,
filterOptions = [],
className,
...props
}) => {
@ -54,13 +55,38 @@ const AsyncSelectList: FC<AsyncSelectListProps> = ({
const [currentPage, setCurrentPage] = useState(1);
const selectedTagsRef = useRef<SelectOption[]>(initialOptions ?? []);
const [optionFilteredCount, setOptionFilteredCount] = useState(0);
const getFilteredOptions = (data: SelectOption[]) => {
if (isEmpty(filterOptions)) {
return data;
}
let count = optionFilteredCount;
const filteredData = data.filter((item) => {
const isFiltered = filterOptions.includes(
item.data?.fullyQualifiedName ?? ''
);
if (isFiltered) {
count = optionFilteredCount + 1;
}
return !isFiltered;
});
setOptionFilteredCount(count);
return filteredData;
};
const loadOptions = useCallback(
async (value: string) => {
setOptions([]);
setIsLoading(true);
try {
const res = await fetchOptions(value, 1);
setOptions(res.data);
setOptions(getFilteredOptions(res.data));
setPaging(res.paging);
setSearchValue(value);
setCurrentPage(1);
@ -119,11 +145,12 @@ const AsyncSelectList: FC<AsyncSelectListProps> = ({
currentTarget.scrollTop + currentTarget.offsetHeight ===
currentTarget.scrollHeight
) {
if (options.length < paging.total) {
// optionFilteredCount added to equalize the options received from the server
if (options.length + optionFilteredCount < paging.total) {
try {
setHasContentLoading(true);
const res = await fetchOptions(searchValue, currentPage + 1);
setOptions((prev) => [...prev, ...res.data]);
setOptions((prev) => [...prev, ...getFilteredOptions(res.data)]);
setPaging(res.paging);
setCurrentPage((prev) => prev + 1);
} catch (error) {

View File

@ -62,7 +62,7 @@ const AddGlossaryTermForm = ({
tags = [],
mutuallyExclusive = false,
references = [],
relatedTerms,
relatedTerms = [],
color,
iconURL,
} = formObj;
@ -252,6 +252,7 @@ const AddGlossaryTermForm = ({
value: data.fullyQualifiedName,
data,
})),
filterOptions: [glossaryTerm?.fullyQualifiedName ?? ''],
},
},
{

View File

@ -0,0 +1,34 @@
/*
* 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.
*/
const getOptions = (initialValue: number, endValue: number) => {
const data = [];
for (let i = initialValue; i < endValue; i++) {
data.push({
label: `tags-${i}`,
value: `tags-${i}`,
data: {
fullyQualifiedName: `tags-${i}`,
},
});
}
return data;
};
export const ASYNC_SELECT_MOCK = {
data: getOptions(0, 10),
paging: {
total: 20,
},
};

View File

@ -26,59 +26,3 @@ export const mockTagList = [
mutuallyExclusive: true,
},
];
export const mockTagsApiResponse = {
data: [
{
id: '0897311f-1321-4e1c-a857-aab7dedc632d',
name: 'Personal',
fullyQualifiedName: 'PersonalData.Personal',
description:
'Data that can be used to directly or indirectly identify a person.',
classification: {
id: '5ce3825b-3227-4326-8beb-37ed2784149e',
type: 'classification',
name: 'PersonalData',
fullyQualifiedName: 'PersonalData',
description:
'Tags related classifying **Personal data** as defined by **GDPR.**<br/><br/>',
deleted: false,
href: 'http://localhost:8585/api/v1/classifications/5ce3825b-3227-4326-8beb-37ed2784149e',
},
version: 0.1,
updatedAt: 1675078969456,
updatedBy: 'admin',
href: 'http://localhost:8585/api/v1/tags/0897311f-1321-4e1c-a857-aab7dedc632d',
deprecated: false,
deleted: false,
provider: 'system',
mutuallyExclusive: false,
},
{
id: '68a9fa7f-9342-404a-b31a-112dea0e0f81',
name: 'SpecialCategory',
fullyQualifiedName: 'PersonalData.SpecialCategory',
description:
'GDPR special category data is personal information of data subjects that is especially sensitive',
classification: {
id: '5ce3825b-3227-4326-8beb-37ed2784149e',
type: 'classification',
name: 'PersonalData',
fullyQualifiedName: 'PersonalData',
description:
'Tags related classifying **Personal data** as defined by **GDPR.**<br/><br/>',
deleted: false,
href: 'http://localhost:8585/api/v1/classifications/5ce3825b-3227-4326-8beb-37ed2784149e',
},
version: 0.1,
updatedAt: 1675078969475,
updatedBy: 'admin',
href: 'http://localhost:8585/api/v1/tags/68a9fa7f-9342-404a-b31a-112dea0e0f81',
deprecated: false,
deleted: false,
provider: 'system',
mutuallyExclusive: false,
},
],
paging: { total: 2 },
};