mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-12-01 18:15:50 +00:00
add virtual list for lineage select (#21312)
* add virtual list for lineage select * update tests * move to lineage constants (cherry picked from commit 4dab2be0ab13c65aa37bddc9a3ff2025956ee75b)
This commit is contained in:
parent
4d4c56fbac
commit
b850319dca
@ -10,9 +10,16 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as React from 'react';
|
||||
import { useLineageProvider } from '../../../../context/LineageProvider/LineageProvider';
|
||||
import { LineagePlatformView } from '../../../../context/LineageProvider/LineageProvider.interface';
|
||||
import { EntityType } from '../../../../enums/entity.enum';
|
||||
import { LineageLayer } from '../../../../generated/settings/settings';
|
||||
@ -30,30 +37,61 @@ const mockedNodes = [
|
||||
],
|
||||
},
|
||||
},
|
||||
position: { x: 100, y: 100 },
|
||||
},
|
||||
{
|
||||
data: {
|
||||
node: {
|
||||
fullyQualifiedName: 'test2',
|
||||
},
|
||||
},
|
||||
position: { x: 200, y: 200 },
|
||||
},
|
||||
{
|
||||
data: {
|
||||
node: {
|
||||
fullyQualifiedName: 'test3',
|
||||
},
|
||||
},
|
||||
position: { x: 300, y: 300 },
|
||||
},
|
||||
{ data: { node: { fullyQualifiedName: 'test2' } } },
|
||||
{ data: { node: { fullyQualifiedName: 'test3' } } },
|
||||
];
|
||||
|
||||
const mockNodeClick = jest.fn();
|
||||
const mockColumnClick = jest.fn();
|
||||
const mockReactFlowInstance = {
|
||||
setCenter: jest.fn(),
|
||||
};
|
||||
|
||||
const defaultMockProps = {
|
||||
activeLayer: [LineageLayer.ColumnLevelLineage],
|
||||
nodes: mockedNodes,
|
||||
onNodeClick: mockNodeClick,
|
||||
onColumnClick: mockColumnClick,
|
||||
platformView: LineagePlatformView.None,
|
||||
reactFlowInstance: mockReactFlowInstance,
|
||||
zoomValue: 1,
|
||||
isEditMode: false,
|
||||
isPlatformLineage: false,
|
||||
};
|
||||
|
||||
jest.mock('../../../../context/LineageProvider/LineageProvider', () => ({
|
||||
useLineageProvider: jest.fn().mockImplementation(() => ({
|
||||
activeLayer: [LineageLayer.ColumnLevelLineage],
|
||||
nodes: mockedNodes,
|
||||
onNodeClick: mockNodeClick,
|
||||
onColumnClick: mockColumnClick,
|
||||
platformView: LineagePlatformView.None,
|
||||
})),
|
||||
useLineageProvider: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('LineageSearchSelect', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useLineageProvider as jest.Mock).mockImplementation(
|
||||
() => defaultMockProps
|
||||
);
|
||||
});
|
||||
|
||||
it('should render select with options', async () => {
|
||||
const { container } = render(<LineageSearchSelect />);
|
||||
const selectElement = screen.getByTestId('lineage-search');
|
||||
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('lineage-search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const selectElm = container.querySelector('.ant-select-selector');
|
||||
@ -65,11 +103,11 @@ describe('LineageSearchSelect', () => {
|
||||
expect(option1).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onNodeClick', async () => {
|
||||
it('should call onNodeClick and center the node', async () => {
|
||||
const { container } = render(<LineageSearchSelect />);
|
||||
const selectElement = screen.getByTestId('lineage-search');
|
||||
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('lineage-search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const selectElm = container.querySelector('.ant-select-selector');
|
||||
@ -83,13 +121,14 @@ describe('LineageSearchSelect', () => {
|
||||
fireEvent.click(option1);
|
||||
|
||||
expect(mockNodeClick).toHaveBeenCalled();
|
||||
expect(mockReactFlowInstance.setCenter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onColumnClick', async () => {
|
||||
const { container } = render(<LineageSearchSelect />);
|
||||
const selectElement = screen.getByTestId('lineage-search');
|
||||
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('lineage-search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const selectElm = container.querySelector('.ant-select-selector');
|
||||
@ -104,4 +143,52 @@ describe('LineageSearchSelect', () => {
|
||||
|
||||
expect(mockColumnClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not render when platform lineage is enabled', () => {
|
||||
(useLineageProvider as jest.Mock).mockImplementation(() => ({
|
||||
...defaultMockProps,
|
||||
isPlatformLineage: true,
|
||||
}));
|
||||
|
||||
const { container } = render(<LineageSearchSelect />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render when platform view is not None', () => {
|
||||
(useLineageProvider as jest.Mock).mockImplementation(() => ({
|
||||
...defaultMockProps,
|
||||
platformView: LineagePlatformView.Service,
|
||||
}));
|
||||
|
||||
const { container } = render(<LineageSearchSelect />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should handle dropdown visibility change', async () => {
|
||||
const { container } = render(<LineageSearchSelect />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('lineage-search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open dropdown
|
||||
await act(async () => {
|
||||
const selectElm = container.querySelector('.ant-select-selector');
|
||||
selectElm && userEvent.click(selectElm);
|
||||
});
|
||||
|
||||
// Close dropdown
|
||||
await act(async () => {
|
||||
const selectElm = container.querySelector('.ant-select-selector');
|
||||
selectElm && userEvent.click(selectElm);
|
||||
});
|
||||
|
||||
// Verify search value is cleared
|
||||
const searchInput = container.querySelector(
|
||||
'.ant-select-selection-search-input'
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(searchInput?.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@ -14,10 +14,16 @@ import { RightOutlined } from '@ant-design/icons';
|
||||
import { Select, Space, Typography } from 'antd';
|
||||
import { DefaultOptionType } from 'antd/lib/select';
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Node } from 'reactflow';
|
||||
import { ZOOM_TRANSITION_DURATION } from '../../../../constants/Lineage.constants';
|
||||
import {
|
||||
DEBOUNCE_TIMEOUT,
|
||||
INITIAL_NODE_ITEMS_LENGTH,
|
||||
NODE_ITEMS_PAGE_SIZE,
|
||||
ZOOM_TRANSITION_DURATION,
|
||||
} from '../../../../constants/Lineage.constants';
|
||||
import { useLineageProvider } from '../../../../context/LineageProvider/LineageProvider';
|
||||
import { LineagePlatformView } from '../../../../context/LineageProvider/LineageProvider.interface';
|
||||
import { Column } from '../../../../generated/entity/data/table';
|
||||
@ -39,10 +45,13 @@ const LineageSearchSelect = () => {
|
||||
platformView,
|
||||
} = useLineageProvider();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [cachedOptions, setCachedOptions] = useState<DefaultOptionType[]>([]);
|
||||
const [allOptions, setAllOptions] = useState<DefaultOptionType[]>([]);
|
||||
const [renderedOptions, setRenderedOptions] = useState<DefaultOptionType[]>(
|
||||
[]
|
||||
);
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Only compute options when dropdown is open
|
||||
const generateNodeOptions = useCallback(() => {
|
||||
const options: DefaultOptionType[] = [];
|
||||
|
||||
@ -51,6 +60,7 @@ const LineageSearchSelect = () => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeOption = {
|
||||
label: (
|
||||
<Space data-testid={`option-${node.fullyQualifiedName}`} size={0}>
|
||||
@ -71,7 +81,6 @@ const LineageSearchSelect = () => {
|
||||
|
||||
const { childrenFlatten = [] } = getEntityChildrenAndLabel(node);
|
||||
|
||||
// Add all columns of the node as separate options
|
||||
childrenFlatten.forEach((column: Column) => {
|
||||
const columnOption = {
|
||||
label: (
|
||||
@ -108,24 +117,88 @@ const LineageSearchSelect = () => {
|
||||
return options;
|
||||
}, [nodes]);
|
||||
|
||||
// Load options when dropdown is opened
|
||||
useEffect(() => {
|
||||
if (isDropdownOpen && cachedOptions.length === 0) {
|
||||
if (isDropdownOpen && allOptions.length === 0) {
|
||||
setIsLoading(true);
|
||||
const options = generateNodeOptions();
|
||||
setCachedOptions(options);
|
||||
setAllOptions(options);
|
||||
setRenderedOptions(options.slice(0, INITIAL_NODE_ITEMS_LENGTH));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isDropdownOpen, cachedOptions.length, generateNodeOptions]);
|
||||
}, [isDropdownOpen, allOptions.length, generateNodeOptions]);
|
||||
|
||||
// Reset cached options when nodes change
|
||||
useEffect(() => {
|
||||
setCachedOptions([]);
|
||||
setAllOptions([]);
|
||||
setRenderedOptions([]);
|
||||
setSearchValue('');
|
||||
}, [nodes]);
|
||||
|
||||
const handleDropdownVisibleChange = useCallback((open: boolean) => {
|
||||
setIsDropdownOpen(open);
|
||||
}, []);
|
||||
const filterOptions = useCallback(
|
||||
(value: string) => {
|
||||
if (value) {
|
||||
const filteredOptions = allOptions.filter((option) =>
|
||||
option.dataLabel
|
||||
?.toString()
|
||||
.toLowerCase()
|
||||
.includes(value.toLowerCase())
|
||||
);
|
||||
setRenderedOptions(filteredOptions);
|
||||
} else {
|
||||
setRenderedOptions(allOptions.slice(0, INITIAL_NODE_ITEMS_LENGTH));
|
||||
}
|
||||
},
|
||||
[allOptions]
|
||||
);
|
||||
|
||||
// Create a debounced version of the filter function
|
||||
const debouncedFilterOptions = useMemo(
|
||||
() => debounce(filterOptions, DEBOUNCE_TIMEOUT),
|
||||
[filterOptions]
|
||||
);
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedFilterOptions.cancel();
|
||||
};
|
||||
}, [debouncedFilterOptions]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchValue(value);
|
||||
debouncedFilterOptions(value);
|
||||
};
|
||||
|
||||
const loadMoreData = () => {
|
||||
if (searchValue) {
|
||||
// If searching, just use the filtered options from allOptions
|
||||
filterOptions(searchValue);
|
||||
} else {
|
||||
const nextLength = Math.min(
|
||||
renderedOptions.length + NODE_ITEMS_PAGE_SIZE,
|
||||
allOptions.length
|
||||
);
|
||||
setRenderedOptions(allOptions.slice(0, nextLength));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropdownVisibleChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsDropdownOpen(open);
|
||||
if (!open) {
|
||||
setSearchValue('');
|
||||
setRenderedOptions(allOptions.slice(0, INITIAL_NODE_ITEMS_LENGTH));
|
||||
debouncedFilterOptions.cancel();
|
||||
}
|
||||
},
|
||||
[allOptions, debouncedFilterOptions]
|
||||
);
|
||||
|
||||
const handlePopupScroll = (e: React.UIEvent<HTMLElement, UIEvent>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 10) {
|
||||
loadMoreData();
|
||||
}
|
||||
};
|
||||
|
||||
const onOptionSelect = useCallback(
|
||||
(value?: string) => {
|
||||
@ -135,7 +208,6 @@ const LineageSearchSelect = () => {
|
||||
if (selectedNode) {
|
||||
const { position } = selectedNode;
|
||||
onNodeClick(selectedNode);
|
||||
// moving selected node in center
|
||||
reactFlowInstance?.setCenter(position.x, position.y, {
|
||||
duration: ZOOM_TRANSITION_DURATION,
|
||||
zoom: zoomValue,
|
||||
@ -160,16 +232,20 @@ const LineageSearchSelect = () => {
|
||||
})}
|
||||
data-testid="lineage-search"
|
||||
dropdownMatchSelectWidth={false}
|
||||
listHeight={300}
|
||||
loading={isLoading}
|
||||
optionFilterProp="dataLabel"
|
||||
optionLabelProp="dataLabel"
|
||||
options={cachedOptions}
|
||||
options={renderedOptions}
|
||||
placeholder={t('label.search-entity', {
|
||||
entity: t('label.lineage'),
|
||||
})}
|
||||
popupClassName="lineage-search-options-list"
|
||||
searchValue={searchValue}
|
||||
onChange={onOptionSelect}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
onPopupScroll={handlePopupScroll}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -128,3 +128,7 @@ export const LINEAGE_EXPORT_HEADERS = [
|
||||
{ field: 'glossaryTerms', title: 'Glossary Terms' },
|
||||
{ field: 'depth', title: 'Level' },
|
||||
];
|
||||
|
||||
export const INITIAL_NODE_ITEMS_LENGTH = 50;
|
||||
export const NODE_ITEMS_PAGE_SIZE = 50;
|
||||
export const DEBOUNCE_TIMEOUT = 300;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user